#
# openlookup/server/http.py
#
# Copyright 2008-2009 Helsinki Institute for Information Technology
# and the authors.
#
# Authors: Ken Rimey <rimey@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

from __future__ import with_statement

import sys
import socket
import os.path
import logging
import traceback
from cgi import escape, FieldStorage, parse_qsl
from urllib import quote, unquote
from urlparse import urlparse
from SocketServer import ThreadingMixIn
from SimpleXMLRPCServer import SimpleXMLRPCServer
from SimpleXMLRPCServer import SimpleXMLRPCRequestHandler

try:
    import json
except ImportError:
    import simplejson as json

from openlookup.lib.sexp import encode, decode
from openlookup.lib.stateless import Stateless
from openlookup.engine.item import decode_item, ValueTooLarge
from openlookup.engine.singleton import SingletonEngine
from openlookup.server.xmlrpc import XMLRPCEngine, METHODS

HTTP_PORT = 5851

class CORSSimpleXMLRPCRequestHandler(SimpleXMLRPCRequestHandler):
    def end_headers(self):
        '''Ensure that the CORS access control header is sent for all requests'''
        self.send_header('Access-Control-Allow-Origin', '*')
        SimpleXMLRPCRequestHandler.end_headers(self)


class RequestHandler(CORSSimpleXMLRPCRequestHandler):
    def do_GET(self):
        parts = urlparse(self.path)
        path = parts.path
        query = parts.query

        if path == '/':
            self.log_message('GET %s', self.path)
            return self.send(200, 'text/html', self.server.get_status_page())

        assert path.startswith('/')
        head = path.split('/', 2)[1]
        path = path[len(head) + 2:]

        if head == 'value':
            self.log_address('singleton')

            if '/' not in path:
                msg = ('Usage:\n'
                       '  /value/-/<label>\n'
                       '  /value/<principal>/<label>')
                return self.send(400, 'text/plain', msg)

            principal, label = path.split('/', 1)
            if principal == '-':
                principal = None
            elif not is_hex(principal):
                msg = 'Expecting hexadecimal or "-" but got "%s".' % principal
                return self.send(400, 'text/plain', msg)

            label = unquote(label)
            return self.do_get_value(label, principal, query)

        elif head == 'sexp':
            self.log_address('singleton')

            params = dict(parse_qsl(query))
            for name in params:
                if name not in ('P', 'L', 'N'):
                    msg = 'Unexpected query parameter "%s".' % name
                    return self.send(400, 'text/plain', msg)

            label_hash = params.get('L')
            principal = params.get('P')
            maxvals = params.get('N')

            if maxvals is not None:
                principal = principal or ''
                try:
                    maxvals = int(maxvals)
                except ValueError:
                    msg = 'Expected integer for max items: %s' % maxvals
                    return self.send(400, 'text/plain', msg)

            if path or not label_hash:
                msg = ('Usage:\n'
                       '  /sexp?L=<label_hash>[&P=<principal>]\n'
                       '  /sexp?L=<label_hash>[&P=<prefix>]&N=<max_items>')
            elif not is_hex(label_hash, 40):
                msg = 'Expected 40 hex digits for label hash: %s' % label_hash
            elif (maxvals is None
                  and not (principal is None or is_hex(principal, 40))):
                msg = 'Expected 40 hex digits for principal: %s' % principal
            elif (maxvals is not None
                  and not (is_hex(principal) and len(principal) <= 40)):
                msg = 'Expected 0-40 hex digits for principal: %s' % principal
            else:
                return self.do_get_sexp(label_hash, principal, maxvals)

            return self.send(400, 'text/plain', msg)

        elif head in ('node_info', 'connection_info', 'xmlrpc_stats', 'singleton_stats'):
            if path or query:
                return self.send(400, 'text/plain', 'Usage: /%s' % head)

            if head == 'node_info':
                data = self.server.get_node_info()
            elif head == 'connection_info':
                data = self.server.get_connection_info()
            elif head == 'xmlrpc_stats':
                data = self.server.get_xmlrpc_stats()
            elif head == 'singleton_stats':
                data = self.server.get_singleton_stats()
            else:
                assert False

            self.log_message('GET %s', self.path)
            return self.send(200, 'application/json', dump_json(data))

        else:
            return self.send(400, 'text/plain', 'Bad request')

    do_HEAD = do_GET

    def do_get_value(self, label, principal, query_component):
        engine = self.server.singleton_engine

        if principal is not None and len(principal) < 40:
            self.tally('query value')
            items = engine.query_items(label, principal, maxvals=2)
            if not items:
                return self.send(404, 'text/plain', 'Not found')
            elif len(items) == 1:
                item = items[0]
                url = '/value/%s/%s' % (item.principal or '-', quote(label))
                if query_component:
                    url += '?' + query_component
                return self.send_redirect(303, url)
            else:
                return self.send(300, 'text/plain', 'Multiple choices')

        self.tally('get value')
        item = engine.get_item(label, principal)
        if item is not None and item.value is not None:
            value = item.decrypt_value(label)
            return self.send(200, None, value,
                             {'X-OpenLookup-TTL': str(item.ttl)})

        if principal:
            return self.send(404, 'text/plain', 'Not found')
        else:
            html = read_file('404.html')
            html = html.replace('KEY', escape(label, True))
            return self.send(404, 'text/html', html)

    def do_get_sexp(self, label_hash, principal, maxvals):
        engine = self.server.singleton_engine
        if maxvals is None:
            self.tally('get item')
            item = engine.get_item_by_label_hash(label_hash, principal)
            if item is not None:
                self.send(200, 'application/octet-stream', item.encode())
            else:
                self.send(404, 'text/plain', 'Not found')
        else:
            self.tally('query item')
            items = engine.query_items_by_label_hash(label_hash, principal,
                                                     maxvals=maxvals)
            response = encode([item.export() for item in items])
            self.send(200, 'application/octet-stream', response)

    def do_POST(self):
        path = urlparse(self.path).path
        if path == '/':
            self.log_address('multivalued')
            CORSSimpleXMLRPCRequestHandler.do_POST(self)
        elif path in ('/sexp', '/sexp/'):
            self.log_address('singleton')
            self.do_post_sexp()
        elif path in ('/value', '/value/', '/value/-', '/value/-/'):
            self.log_address('singleton')
            self.do_post_value()
        else:
            self.send(400, 'text/plain', 'Bad request')

    def do_post_sexp(self):
        engine = self.server.singleton_engine
        self.tally('put item')
        try:
            size = int(self.headers['content-length'])
            sexp = decode(self.rfile.read(size))
            item = decode_item(sexp)
            engine.put_item(item)
            self.send(200)
        except (ValueError, TypeError), e:
            self.send(400, 'text/plain', str(e))

    def do_post_value(self):
        engine = self.server.singleton_engine
        self.tally('put value')
        try:
            fields = self.read_fields()

            if 'key' not in fields:
                raise ValueError('"key" is a required field.')
            key = fields.getfirst('key')

            # XXX Support removal.
            if 'value' not in fields:
                raise ValueError('"value" is a required field.')
            value = fields.getfirst('value')

            if 'ttl' in fields:
                ttl = int(fields.getfirst('ttl'))
            else:
                ttl = None

            engine.put(key, value, ttl=ttl)

            # XXX This is unlikely to work reliably with round-robin DNS.
            self.send_redirect(303, '/value/-/' + quote(key))
        except ValueTooLarge, e:
            self.send(413, 'text/plain', str(e))
        except ValueError, e:
            self.send(400, 'text/plain', str(e))

    def do_OPTIONS(self):
        headers = {
         'Access-Control-Allow-Methods':'POST, GET, OPTIONS',
         'Access-Control-Allow-Headers':'Connection, Content-length, Content-type',
         'Access-Control-Max-Age':'1728000'
        }
        self.send(200, None, '', headers)

    def read_fields(self, method='POST'):
        return FieldStorage(fp=self.rfile,
                            headers=self.headers,
                            environ={'REQUEST_METHOD': method})

    def send_redirect(self, code, url):
        if url.startswith('/') and 'host' in self.headers:
            url = 'http://' + self.headers['host'] + url
        self.send(303, None, '', {'Location': url})

    def send(self, code, ctype=None, body='', additional_headers={}):
        try:
            self.send_response(code)
            self.send_header('Content-Length', str(len(body)))
            if ctype:
                self.send_header('Content-Type', ctype)
            for name, value in additional_headers.iteritems():
                self.send_header(name, value)
            self.end_headers()
            if self.command != 'HEAD':
                self.wfile.write(body)
            self.wfile.flush()
            self.connection.shutdown(1)
        except socket.error, e:
            logging.warning(
                'Got socket error while processing HTTP request from %s: %s',
                self.address_string(), e)

    def log_address(self, store):
        self.server.log_address(self.client_address[0], store)

    def tally(self, method):
        self.server.stats[method] = self.server.stats.get(method, 0) + 1

    def log_message(self, format, *args):
        address = self.address_string()
        message = format % args
        logging.info('%s %s', address, message)

class HTTPServer(ThreadingMixIn, SimpleXMLRPCServer):
    def __init__(self, address, filename, fast, threading, daemon=None):
        self.daemon_threads = True # for ThreadingMixIn
        SimpleXMLRPCServer.__init__(self, address,
                                    requestHandler=RequestHandler,
                                    logRequests=False)
        self.threading = threading
        self.daemon = daemon

        self.addresses = {'multivalued': set(), 'singleton': set()}
        self.stats = {}

        self.singleton_engine = Stateless(SingletonEngine, filename, fast=fast)

        self.xmlrpc_engine = XMLRPCEngine(filename, fast=fast)
        for method in METHODS:
            function = getattr(self.xmlrpc_engine, 'handle_' + method)
            self.register_function(function, method)

    def process_request(self, request, client_address):
        if self.threading:
            ThreadingMixIn.process_request(self, request, client_address)
        else:
            SimpleXMLRPCServer.process_request(self, request, client_address)

    def handle_error(self, request, client_address):
        msg = ('Got exception while processing HTTP request from %s:'
               % client_address[0])
        exc_type, exc_value = sys.exc_info()[:2]
        if exc_type == socket.error:
            lines = traceback.format_exception_only(exc_type, exc_value)
            lines = [s.rstrip() for s in lines]
            logging.warning('\n'.join([msg] + lines))
        else:
            logging.exception(msg)

    def loop(self):
        host, port = self.server_address[:2]
        if not host:
            logging.info('Serving HTTP and XML-RPC on port %s.', port)
        else:
            logging.info('Serving HTTP and XML-RPC on %s port %s.', host, port)

        try:
            self.serve_forever()
        except KeyboardInterrupt:
            pass

    def get_status_page(self):
        data = [('TITLE', self.get_title()),
                ('NODE_INFO', self.get_formatted_node_info()),
                ('CONNECTION_INFO', self.get_formatted_connection_info()),
                ('SINGLETON_CLIENT_INFO', self.get_formatted_singleton_stats()),
                ('MULTIVALUED_CLIENT_INFO', self.get_formatted_xmlrpc_stats())]

        html = read_file('template.html')
        for token, value in data:
            html = html.replace(token, value)
        return html

    def get_title(self):
        if self.daemon:
            name = self.daemon.get_name()
            if name:
                return 'Openlookup: %s' % escape(name)

        return 'Openlookup Node Status'

    def get_formatted_node_info(self):
        return '\n'.join(self.gen_formatted_node_info())

    def get_formatted_connection_info(self):
        return '\n'.join(self.gen_formatted_connection_info())

    def get_formatted_singleton_stats(self):
        return '\n'.join(self.gen_formatted_singleton_stats())

    def get_formatted_xmlrpc_stats(self):
        return '\n'.join(self.gen_formatted_xmlrpc_stats())

    def gen_formatted_node_info(self):
        info = self.get_node_info()

        yield '<table>'
        for label, value in info:
            if not label:
                yield '  <tr><td height="8"></td></tr>'
            elif value in ('', None):
                continue
            else:
                yield '  <tr>'
                yield '    <td><em>%s: &nbsp;</em></td>' % label
                yield '    <td>%s</td>' % escape(str(value))
                yield '  </tr>'
        yield '</table>'

    def gen_formatted_connection_info(self):
        rows = self.get_connection_info()
        if len(rows) < 2:
            return

        labels = rows.pop(0)

        yield '<h3>Connections</h3>'
        yield '<table width="80%">'
        yield '  <tr>'
        for heading in labels:
            yield '    <td align="center"><em>%s</em></td>' % heading
        yield '  </tr>'

        for row in rows:
            info = dict(zip(labels, row))
            host = info['IP address']
            port = info['Access port']
            if host and port:
                html = '<a href="http://%s:%s/">%s</a>' % (host, port, host)
                info['IP address'] = html

            yield '  <tr>'
            for label in labels:
                yield '    <td align="center">%s</td>' % str(info[label])
            yield '  </tr>'

        yield '</table>'

    def gen_formatted_singleton_stats(self):
        calls_by_method = self.get_singleton_stats()
        if not calls_by_method:
            return

        methods = ('query value', 'get value', 'put value',
                   'query item', 'get item', 'put item')

        yield '<h3>Singleton Client Stats</h3>'
        yield '<table width="60%">'
        yield '  <tr>'
        for method in methods:
            yield '    <td align="center"><em>%s</em></td>' % method
        yield '  </tr>'
        yield '  <tr>'
        for method in methods:
            count = calls_by_method.get(method, 0)
            yield '    <td align="center">%d</td>' % count
        yield '  </tr>'
        yield '</table>'

    def gen_formatted_xmlrpc_stats(self):
        stats_by_app = self.get_xmlrpc_stats()
        if not stats_by_app:
            return

        yield '<h3>Multivalued Client Stats</h3>'
        yield '<table width="60%">'
        yield '  <tr>'
        yield '    <td><em>Application</em></td>'
        for method in METHODS:
            yield '    <td align="center"><em>%s()</em></td>' % method
        yield '  </tr>'

        for application, calls_by_method in stats_by_app:
            yield '  <tr>'
            yield '    <td>%s</td>' % escape(application)
            for method in METHODS:
                count = calls_by_method.get(method, 0)
                yield '    <td align="center">%d</td>' % count
            yield '  </tr>'

        yield '</table>'

    def get_node_info(self):
        if self.daemon:
            return list(self.daemon.gen_node_info())
        else:
            return []

    def get_connection_info(self):
        if self.daemon and hasattr(self.daemon, 'sync_mgr'):
            sync_mgr = self.daemon.sync_mgr
            return list(sync_mgr.gen_connection_info())
        else:
            return []

    def get_singleton_stats(self):
        return dict(self.stats)

    def get_xmlrpc_stats(self):
        return sorted(self.xmlrpc_engine.stats.items())

    def log_address(self, ip, store):
        addresses = self.addresses[store]
        if ip not in addresses:
            addresses.add(ip)
            logging.info('%s %s' % (socket.getfqdn(ip), store.upper()))

def is_hex(s, n=None):
    if n is not None and len(s) != n:
        return False

    return all(c in '0123456789abcdef' for c in s)

def read_file(filename):
    dir = os.path.dirname(__file__)
    path = os.path.join(dir, '../..', 'html', filename)
    return open(path).read()

def dump_json(obj):
    return json.dumps(obj, indent=2, sort_keys=True)

def create_http_server(filename, fast, threading,
                       host='', port=HTTP_PORT, daemon=None):
    return HTTPServer((host, port), filename, fast, threading, daemon)
